<!DOCTYPE html>
<html>
  <head>
    <title>Geo Photo Album</title>
    <meta name="viewport"
        content="width=device-width, initial-scale=1.0, user-scalable=no">
    <meta charset="UTF-8">
    <style type="text/css">
      html, body {
        margin: 0;
        padding: 0;
        height: 100%;
        display: table;
        width: 100%
      }
      input.hasDatepicker {
        width: 80px;
      }
      #map_canvas {
        margin: 10px;
        height: 100%;
        display: table-row;
      }
      #date_range {
        bottom: 0px;
        padding: 10px;
        z-index: 2;
        position: absolute;
        right: 100px;
      }
      body {
        font-size: 62.5%;
        font-family: "Trebuchet MS", "Helvetica", "Arial",  "Verdana", "sans-serif";
      }

      img[src^="da"]:not(.fadeIn):not(.fadePartial):not(.fadeOut) {
        opacity: 0.7;
        animation-name: fadeInFrames;
        animation-duration: 400ms;
        animation-iteration-count: 1;
        animation-direction: normal;
        animation-timing-function: linear;
        animation-play-state: running;
        animation-fill-mode: forwards;

        -moz-animation-name: fadeInFrames;
        -moz-animation-duration: 400ms;
        -moz-animation-iteration-count: 1;
        -moz-animation-direction: normal;
        -moz-animation-timing-function: linear;
        -moz-animation-play-state: running;
        -moz-animation-fill-mode: forwards;

        -webkit-animation-name: fadeInFrames;
        -webkit-animation-duration: 400ms;
        -webkit-animation-iteration-count: 1;
        -webkit-animation-direction: normal;
        -webkit-animation-timing-function: linear;
        -webkit-animation-play-state: running;
        -webkit-animation-fill-mode: forwards;

        -o-animation-name: fadeInFrames;
        -o-animation-duration: 400ms;
        -o-animation-iteration-count: 1;
        -o-animation-direction: normal;
        -o-animation-timing-function: linear;
        -o-animation-play-state: running;
        -o-animation-fill-mode: forwards;
      }
      @keyframes fadeInFrames { 0% {opacity: 0;} 100% {opacity: 0.7;}}
      @-moz-keyframes fadeInFrames { 0% {opacity: 0;} 100% {opacity: 0.7;}}
      @-webkit-keyframes fadeInFrames { 0% {opacity: 0;} 100% {opacity: 0.7;}}
      @-o-keyframes fadeInFrames { 0% {opacity: 0;} 100% {opacity: 0.7;}}
      
      img[src^="da"].fadeOut {
        opacity: 0;
        transition: opacity 1s;
        -moz-transition: opacity 1s;
        -webkit-transition: opacity 1s;
        -o-transition: opacity 1s;
      }

      img[src^="da"].fadePartial {
        opacity: 0.7;
        transition: opacity 1s;
        -moz-transition: opacity 1s;
        -webkit-transition: opacity 1s;
        -o-transition: opacity 1s;
      }
      
      img[src^="da"].fadeIn {
        opacity: 1;
        transition: opacity 1s;
        -moz-transition: opacity 1s;
        -webkit-transition: opacity 1s;
        -o-transition: opacity 1s;
      }
      
      .fancybox-tmp video {
        max-width: none;
        max-height: none;
      }

      video {
        max-width: 100%;
        max-height: 100%;
      }
      
      .fancybox-nav.fancybox-nav-video {
        height: auto;
        top: 0;
        bottom: 35px;
      }
    </style>
    <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.21/themes/start/jquery-ui.css">
    <script type="text/javascript" src="http://maps.googleapis.com/maps/api/js?sensor=false"></script>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.js"></script>
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/mootools/1.4.5/mootools-yui-compressed.js"></script>
    <script type="text/javascript">
      if (typeof jQuery == 'undefined') {
        document.write(unescape("%3Cscript src='/path/to/your/jquery' type='text/javascript'%3E%3C/script%3E"));
      }
    </script>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.21/jquery-ui.min.js"></script>
    <script type="text/javascript">
      if (typeof jQuery == 'undefined') {
        document.write(unescape("%3Cscript src='/path/to/your/jquery' type='text/javascript'%3E%3C/script%3E"));
      }
    </script>
    <script src="crypto-js/core.js"></script>
    <script src="crypto-js/cipher-core.js"></script>
    <script src="crypto-js/sha1.js"></script>
    <script src="crypto-js/hmac.js"></script>
    <script src="crypto-js/pbkdf2.js"></script>
    <script src="crypto-js/aes.js"></script>
    <script src="crypto-js/mode-ecb.js"></script>
    <script src="crypto-js/pad-nopadding.js"></script>
    <script src="crypto-js/enc-base64.js"></script>

    <!-- Add mousewheel plugin (this is optional) -->
    <script type="text/javascript" src="fancyBox/lib/jquery.mousewheel-3.0.6.pack.js"></script>

    <!-- Add fancyBox main JS and CSS files -->
    <script type="text/javascript" src="fancyBox/source/jquery.fancybox.js"></script>
    <link rel="stylesheet" type="text/css" href="fancyBox/source/jquery.fancybox.css" media="screen" />

    <!-- Add Button helper (this is optional) -->
    <link rel="stylesheet" type="text/css" href="fancyBox/source/helpers/jquery.fancybox-buttons.css" />
    <script type="text/javascript" src="fancyBox/source/helpers/jquery.fancybox-buttons.js"></script>

    <!-- Add Thumbnail helper (this is optional) -->
    <link rel="stylesheet" type="text/css" href="fancyBox/source/helpers/jquery.fancybox-thumbs.css" />
    <script type="text/javascript" src="fancyBox/source/helpers/jquery.fancybox-thumbs.js"></script>

    <!-- Add Media helper (this is optional) -->
    <script type="text/javascript" src="fancyBox/source/helpers/jquery.fancybox-media.js"></script>

    <script type="text/javascript">
      'use strict';
      var map;
      var routePolyline;
      var gpsPoints = [];
      var photoInfo = [];
      var passwordInfo = [];
      var passwords = [];
      var photoInfoDisplayed = [];
      var tileFiles = {};
      var tilesLoaded = 0;
      var minDateTime;
      var maxDateTime;
      var startDateTime;
      var endDateTime;
      var photoInfoWindow;
      var tileGenerationDateTime = Date.parse(new Date()); //in case the tile file doesn't have it
      function degreesToRadians(deg) {
        return deg * (Math.PI / 180);
      }

      function bound(value, opt_min, opt_max) {
        if (opt_min !== null) {
          value = Math.max(value, opt_min);
        }
        if (opt_max !== null) {
          value = Math.min(value, opt_max);
        }
        return value;
      }

      function fromLatLngToPoint(latLng, opt_point) {
        var point = opt_point || new google.maps.Point(0, 0);

        point.x = 128 + latLng.lng() * 256 / 360;

        // NOTE(appleton): Truncating to 0.9999 effectively limits latitude to
        // 89.189.  This is about a third of a tile past the edge of the world
        // tile.
        var siny = bound(Math.sin(degreesToRadians(latLng.lat())), -0.9999,
            0.9999);
        point.y = 128 + 0.5 * Math.log((1 + siny) / (1 - siny)) *
            -(256 / (2 * Math.PI));
        return point;
      }

      function mergeSortGpsPoints(A, B) {
        var ret = [];
        var i = 0;
        var j = 0;
        while(i < A.length && j < B.length) {
          if((A[i][0]- B[j][0]) <= 0) {
            ret.push(A[i]);
            i++;
          } else {
            ret.push(B[j]);
            j++;
          }
        }
        while (i < A.length) {
          ret.push(A[i]);
          i++;
        }
        while (j < B.length) {
          ret.push(B[j]);
          j++;
        }
        return(ret);
      }
      //find first element that returns true, returns this.length if no true elements
      //assume that list is sorted so that all the false elements are before all the true elements
      Array.prototype.indexOfLambda = function(f) {
        var len = this.length;
        if(len === 0) {
          return(len);
        }
        var start = 0;
        var end = len-1;
        if(!f(this[end])) {
          return(len);
        }
        while(start != end) { //start < end is true at this point
          var current = Math.floor((start+end)/2); //current >= start && current < end at this point
          if(f(this[current])) { //answer is at or before current
            end = current; //progress
          } else { //answer is after current.  current < end so current+1 <= end
            start = current+1; //progress
          }
        }
        return(start);
      };

      function getBase64Image(img) {
        // Create an empty canvas element
        var canvas = document.createElement("canvas");
        var minSide = Math.min(img.width, img.height);
        canvas.width = minSide;
        canvas.height = minSide;

        // Copy the image contents to the canvas
        var ctx = canvas.getContext("2d");
        ctx.strokeStyle=$(".ui-slider-range").css("background-color");
        //ctx.fillStyle="rgba(0,0,255,0.5)";
        ctx.arc(minSide/2,minSide/2,minSide/2,0,Math.PI*2,true);
        ctx.clip();
        //ctx.globalAlpha = 0;
        ctx.drawImage(img, (img.width-minSide)/2, (img.height-minSide)/2, (img.width-minSide)/2+minSide, (img.height-minSide)/2+minSide, 0, 0, minSide, minSide);
        ctx.lineWidth=4;
        ctx.beginPath();
        ctx.arc(minSide/2,minSide/2,minSide/2-1,0,Math.PI*2,true);
        //ctx.fill();
        ctx.stroke();

        // Get the data-URL formatted image
        // Firefox supports PNG and JPEG. You could check img.src to guess the
        // original format, but be aware the using "image/jpg" will re-encode the image.
        var dataURL = canvas.toDataURL("image/png");

        return(dataURL); //.replace(/^data:image\/(png|jpg);base64,/, "");
      }
      
      function markerAnimation(animation_id, currentPhoto) {
        if(animation_id=="fadeOut") {
          $('div[style*="z-index: ' + (1000 + currentPhoto[1]) + ';"] img').removeClass("fadeIn").removeClass("fadePartial").addClass("fadeOut");
          currentPhoto["marker"].setTitle("");
          if(currentPhoto["marker"].getClickable()) {
            currentPhoto["marker"].setClickable(false);
          }
          //currentPhoto["marker"].setShape(currentPhoto["shape"]);
          if($('div[style*="z-index: ' + (1000 + currentPhoto[1]) + ';"] img').css("opacity") === 0) {
            currentPhoto["marker"].setMap(null);
            currentPhoto["marker"]=null;
          }
        } else if(animation_id=="fadeIn") {
          $('div[style*="z-index: ' + (1000 + currentPhoto[1]) + ';"] img').removeClass("fadeOut").removeClass("fadePartial").addClass("fadeIn");
          currentPhoto["marker"].setTitle(currentPhoto[0]);
          if(!currentPhoto["marker"].getClickable()) {
            currentPhoto["marker"].setClickable(true);
          }
          //currentPhoto["marker"].setShape(currentPhoto["shape"]);
        } else if(animation_id=="fadePartial") {
          $('div[style*="z-index: ' + (1000 + currentPhoto[1]) + ';"] img').removeClass("fadeOut").removeClass("fadeIn").addClass("fadePartial");
          currentPhoto["marker"].setTitle(currentPhoto[0]);
          if(!currentPhoto["marker"].getClickable()) {
            currentPhoto["marker"].setClickable(true);
          }
          //currentPhoto["marker"].setShape(currentPhoto["shape"]);
        }
      }
      
      function decryptHex(keyHex, ivCipherHex) {
        var iv = CryptoJS.enc.Hex.parse(ivCipherHex.substr(0, 128/4));
        var cipher = CryptoJS.enc.Hex.parse(ivCipherHex.substr(128/4));
        var key = CryptoJS.enc.Hex.parse(keyHex);
        var aesDecryptor = CryptoJS.algo.AES.createDecryptor(key, { iv: iv, mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding });
        var ciphertext0 = aesDecryptor.process(cipher);
        var ciphertext1 = aesDecryptor.finalize();
        return(CryptoJS.enc.Hex.stringify(ciphertext0.concat(ciphertext1)));
      }
      
      function getImageKey(currentPhoto) {
        var imageKey;
        $.each(currentPhoto[5], function(index, imageKeyPair) {
          $.each(passwords, function(index, plainAndDerived) {
            //check if key decrypts the pair
            var key0 = decryptHex(plainAndDerived[1], imageKeyPair[0]);
            var key1 = decryptHex(plainAndDerived[1], imageKeyPair[1]);
            if((parseInt(key0.substr(0,2),16)+1)%255 == parseInt(key1.substr(0,2),16) &&
               key0.substr(2) == key1.substr(2)) {
              imageKey = key0; //found!
              return(false);
            }
          });
          if(imageKey) {
            return(false);
          }
        });
        return(imageKey);
      }
      
      function parseDate(val) {
        if(val === null) {
          return(null);
        }
        if(typeof(val) === "number" || /^[0-9]+$/.exec(val)) {
          return new Date(parseInt(val));
        }
        if($.browser.msie && typeof(val) == "string") {
          val = val.replace(/\.[0-9]*/,"");
        }
        return new Date(val);
      }
      
      function load_photos() {
        var bounds = map.getBounds();
        photoInfoDisplayed = [];
        //first, fill photoInfoDisplayed with the indicies of all photos to show
        $.each(photoInfo, function(currentIndex, currentPhoto) {
          currentPhoto["displayed"] = false;
          currentPhoto["hides"] = [];
          var currentLatLng = new google.maps.LatLng(currentPhoto[3],currentPhoto[4]);
          var current_time = (parseDate(currentPhoto[2])).getTime();
          var start_time = startDateTime.getTime();
          var end_time = endDateTime.getTime();
          if(current_time >= start_time && current_time < end_time && bounds.contains(currentLatLng)) {
            var currentPoint = fromLatLngToPoint(currentLatLng);
            currentPhoto["displayed"] = true;
            if(currentPhoto.length > 5 && !currentPhoto["imageKey"]) {
              var imageKey = getImageKey(currentPhoto);
              if(imageKey) {
                currentPhoto["imageKey"] = imageKey;
              } else {
                currentPhoto["displayed"] = false;
              }
            }
            if(currentPhoto["displayed"]) {
              for(var otherIndex = 0; otherIndex < photoInfoDisplayed.length; otherIndex++) {
                var otherPhoto = photoInfo[photoInfoDisplayed[otherIndex]];
                if(otherPhoto["displayed"]) {
                  var otherPoint = fromLatLngToPoint(new google.maps.LatLng(otherPhoto[3],otherPhoto[4]));
                  var x2 = (currentPoint.x-otherPoint.x) * (1<<map.getZoom());
                  x2 = x2*x2;
                  var y2 = (currentPoint.y-otherPoint.y) * (1<<map.getZoom());
                  y2 = y2*y2;
                  if(x2+y2 < 32*32) {
                    currentPhoto["displayed"] = false;
                    otherPhoto["hides"].push(currentPhoto);
                    break;
                  }
                }
              }
            }
          }
          if(currentPhoto["displayed"]) {
            photoInfoDisplayed.push(currentIndex);
            if(currentPhoto.hasOwnProperty("marker") && currentPhoto["marker"]) {
              markerAnimation("fadePartial", currentPhoto);
            } else {
              var img = document.createElement("img");
              if(currentPhoto["imageKey"]) { //must decrypt this image, we have imageKey to do it
                var xhr = new XMLHttpRequest();
                xhr.overrideMimeType('image/gif; charset=x-user-defined');
                xhr.onreadystatechange = function() {
                  if(xhr.readyState == 4) {
                    var key = CryptoJS.enc.Hex.parse(currentPhoto["imageKey"]);
                    var iv = CryptoJS.enc.Latin1.parse(xhr.response.substr(0,16));
                    var cipher = CryptoJS.enc.Latin1.parse(xhr.response.substr(16));
                    var aesEncryptor = CryptoJS.algo.AES.createDecryptor(key, { iv: iv });
                    var ciphertext0 = aesEncryptor.process(cipher);
                    var ciphertext1 = aesEncryptor.finalize();
                    img.src = 'data:image/png;base64,' + CryptoJS.enc.Base64.stringify(ciphertext0.concat(ciphertext1));
                  }
                }
                if(!(/\.(jpg|jpeg)$/i.test(currentPhoto[0]))) {
                  xhr.open("GET", "thumbnails/" + currentPhoto[0].replace(/\.[^.]*$/, ".jpg") + ".aes", true);
                } else {
                  xhr.open("GET", "thumbnails/" + currentPhoto[0] + ".aes", true);
                }
                xhr.send();
              } else if(!(/\.(jpg|jpeg)$/i.test(currentPhoto[0]))) {
                img.src = "thumbnails/" + currentPhoto[0].replace(/\.[^.]*$/, ".jpg");
              } else {
                img.src = "thumbnails/" + currentPhoto[0];
              }
              img.onload = function(e) {
                var side = Math.min(this.width,this.height);
                var mi = new google.maps.MarkerImage(getBase64Image(this), null, null, new google.maps.Point(side/2,side/2), null);
                currentPhoto["shape"] = {type: "circle", coords: [side/2,side/2,side/2]};
                currentPhoto["marker"] = new google.maps.Marker({position: new google.maps.LatLng(currentPhoto[3],currentPhoto[4]), title:currentPhoto[0],
                  icon: mi,
                  visible: true,
                  zIndex: 1000 + currentPhoto[1],
                  optimized: false, //just to make the img instead of canvas
                  shape: currentPhoto["shape"],
                  map: map
                });
                google.maps.event.addListener(currentPhoto["marker"], 'mouseover', function () {
                  if(currentPhoto["displayed"]) {
                      markerAnimation("fadeIn", currentPhoto);
                  }
                });
                google.maps.event.addListener(currentPhoto["marker"], 'mouseout', function () {
                  if(currentPhoto["displayed"]) {
                    markerAnimation("fadePartial", currentPhoto);
                  }
                });
                google.maps.event.addListener(currentPhoto["marker"], 'click', function () {
                  if(currentPhoto["displayed"]) {
                    var fancyPhotos = [];
                    $.each([currentPhoto].concat(currentPhoto["hides"]), function(index, hiddenPhoto) {
                      var imgSrc;
                      var thumbSrc;
                      if(hiddenPhoto["imageKey"]) { //must decrypt this image, we have imageKey to do it
                        thumbSrc = function() {
                          var xhr = new XMLHttpRequest();
                          xhr.overrideMimeType('image/gif; charset=x-user-defined');
                          if(!(/\.(jpg|jpeg)$/i.test(hiddenPhoto[0]))) {
                            xhr.open("GET", "thumbnails/" + hiddenPhoto[0].replace(/\.[^.]*$/, ".jpg") + ".aes", false);
                          } else {
                            xhr.open("GET", "thumbnails/" + hiddenPhoto[0] + ".aes", false);
                          }
                          xhr.send();
                          if(xhr.readyState == 4) {
                            var key = CryptoJS.enc.Hex.parse(hiddenPhoto["imageKey"]);
                            var iv = CryptoJS.enc.Latin1.parse(xhr.response.substr(0,16));
                            var cipher = CryptoJS.enc.Latin1.parse(xhr.response.substr(16));
                            var aesEncryptor = CryptoJS.algo.AES.createDecryptor(key, { iv: iv });
                            var ciphertext0 = aesEncryptor.process(cipher);
                            var ciphertext1 = aesEncryptor.finalize();
                            return('data:image/png;base64,' + CryptoJS.enc.Base64.stringify(ciphertext0.concat(ciphertext1)));
                          }
                          return(null);
                        }
                        imgSrc = function() {
                          var xhr = new XMLHttpRequest();
                          xhr.overrideMimeType('image/gif; charset=x-user-defined');
                          xhr.open("GET", "photos/" + hiddenPhoto[0] + ".aes", false);
                          xhr.send();
                          if(xhr.readyState == 4) {
                            var key = CryptoJS.enc.Hex.parse(hiddenPhoto["imageKey"]);
                            var iv = CryptoJS.enc.Latin1.parse(xhr.response.substr(0,16));
                            var cipher = CryptoJS.enc.Latin1.parse(xhr.response.substr(16));
                            var aesEncryptor = CryptoJS.algo.AES.createDecryptor(key, { iv: iv });
                            var ciphertext0 = aesEncryptor.process(cipher);
                            var ciphertext1 = aesEncryptor.finalize();
                            return('data:image/png;base64,' + CryptoJS.enc.Base64.stringify(ciphertext0.concat(ciphertext1)));
                          }
                          return(null);
                        }
                        fancyPhotos.push({href: imgSrc, title: hiddenPhoto[0] + "<br />" + hiddenPhoto[2], thumbnail: thumbSrc});
                      } else {
                        if(!(/\.(jpg|jpeg)$/i.test(hiddenPhoto[0]))) {
                          thumbSrc = "thumbnails/" + hiddenPhoto[0].replace(/\.[^.]*$/, ".jpg");
                        } else {
                          thumbSrc = "thumbnails/" + hiddenPhoto[0];
                        }
                        if(!(/\.webm$/i.test(hiddenPhoto[0]))) {
                          imgSrc = "photos/" + hiddenPhoto[0];
                          fancyPhotos.push({href: imgSrc, title: hiddenPhoto[0] + "<br />" + hiddenPhoto[2], thumbnail: thumbSrc});
                        } else {
                          fancyPhotos.push({metadataReady: false, showReady: false,
                                            title: hiddenPhoto[0] + "<br />" + hiddenPhoto[2], thumbnail: thumbSrc, type: "inline", aspectRatio: true, scrolling: "no",
                                            afterShow: function() {
                                              $(".fancybox-nav").addClass("fancybox-nav-video");
                                              this.element[0].showReady = true;
                                              if(this.element[0].showReady && this.element[0].metadataReady) {
                                                if(this.element[0].width != this.element[0].videoWidth || this.element[0].height != this.element[0].videoHeight) {
                                                  this.element[0].width = this.element[0].videoWidth;
                                                  this.element[0].height = this.element[0].videoHeight;
                                                  $.fancybox.update();
                                                }
                                              }
                                            },
                                            href: $('<video controls="controls"><source src="photos/' + hiddenPhoto[0] + '"></video>').on("loadedmetadata", function() {
                                              this.metadataReady = true;
                                              if(this.showReady && this.metadataReady) {
                                                if(this.width != this.videoWidth || this.height != this.videoHeight) {
                                                  this.width = this.videoWidth;
                                                  this.height = this.videoHeight;
                                                  $.fancybox.update();
                                                }
                                              }
                                            })
                                          });
                          /*fancyPhotos.push($('<video controls="controls"><source src="photos/' + hiddenPhoto[0] + '"></video>').on("canplay", function () {
                            //load stuff
                            //$.fancybox.wrap.removeClass('fancybox-tmp');
                            //$.fancybox._setDimension();
                          }));*/
                        }
                      }
                    });
                    $.fancybox.open(
                      fancyPhotos,
                      {
                        helpers : {
                          thumbs : {
                            width: 64,
                            height: 64,
                            source: function (item) {
                              return(item["thumbnail"]);
                            }
                          }
                        }, beforeLoad: function() {
                          if($.isFunction(this.href)) {
                            this.href = this.href();
                          }
                          if($.isFunction(this.thumbnail)) {
                            this.thumbnail = this.thumbnail();
                          }
                          if(/^data:/.exec(this.href)) {
                            if(/^data:image/.exec(this.href)) {
                              this.type = 'image';
                            }
                          }
                        }
                      });
                    //photoInfoWindow.setContent('<img style="max-width: 100%; max-height: 100%" src="' + imgSrc + '">');
                    //photoInfoWindow.open(map, currentPhoto["marker"]);
                  }
                });
              }
            }
          } else {
            if(currentPhoto.hasOwnProperty("marker") && currentPhoto["marker"]) {
              markerAnimation("fadeOut", currentPhoto);
            }
          }
          //document.body.appendChild(img);
        });
      }

      function load_polyline(force, autoZoom) {
        routePolyline.setOptions({strokeColor: $(".ui-slider-range").css("background-color")});
        if(tilesLoaded > 40) {
          console.debug("reset tiles");
          $.each(tileFiles, function (zoom_level, val) {
            $.each(val, function (logTileSize, val) {
              $.each(val, function (tileFilename, alreadyLoaded) {
                tileFiles[zoom_level][logTileSize][tileFilename] = 0;
              });
            });
          });
          gpsPoints = [];
          tilesLoaded = 0;
        }
        var zoom_level = map.getZoom();
        var bounds = map.getBounds();
        var sw = bounds.getSouthWest();
        var ne = bounds.getNorthEast();
        var sw_point = fromLatLngToPoint(sw);
        var ne_point = fromLatLngToPoint(ne);
        for(var current_zoom in tileFiles) {
          if(current_zoom <= zoom_level) {
            //tile is 2048x2048.  2048 is 2**11.
            var current_west = bound(sw_point.x,0,255) * (1 << current_zoom);
            var current_south = bound(sw_point.y,0,255) * (1 << current_zoom);
            var current_east = bound(ne_point.x,0,255) * (1 << current_zoom);
            var current_north = bound(ne_point.y,0,255) * (1 << current_zoom);
            for (var logTileSize in tileFiles[current_zoom]) {
              for(var i = Math.floor(current_west>>logTileSize); i <= Math.floor(current_east>>logTileSize); i++) {
                for(var j = Math.floor(current_north>>logTileSize); j <= Math.floor(current_south>>logTileSize); j++) {
                  var tile_filename = "tiles/" + current_zoom + '/tile_' + current_zoom + '_' + logTileSize + "_" + i + '_' + j + '.json';
                  if(!tileFiles[current_zoom][logTileSize].hasOwnProperty(tile_filename) ||
                      tileFiles[current_zoom][logTileSize][tile_filename]) {
                    continue;
                  }
                  force = false; //the JSON call will eventually redraw the route
                  tileFiles[current_zoom][logTileSize][tile_filename] = 1;
                  $.getJSON(tile_filename + "?_=" + tileGenerationDateTime, function(data) {
                    $.each(data, function(key, val) {
                      if(key == 'path') {
                        tilesLoaded++;
                        var newPath = [];
                        var newBounds = new google.maps.LatLngBounds();
                        var start_time = startDateTime.getTime();
                        var end_time = endDateTime.getTime();
                        gpsPoints = mergeSortGpsPoints(gpsPoints, val);
                        updatePath(true, autoZoom);
                      }
                    });
                  });
                }
              }
            }
          }
        }
        if(force || autoZoom) { //we must re-draw the route because there were no JSON calls that will do it.
          updatePath(force, autoZoom);
        }
      }
      
      var updatePath = function() {
        var currentPath = [];
        var currentBounds = new google.maps.LatLngBounds();
        var currentStartTime = null;
        var currentEndTime = null;
        var startGpsIndex = null;
        var endGpsIndex = null;
        var process = null;
        return function(force, autoZoom, keepGoing) {
          if(!keepGoing ||
             currentStartTime === null || currentStartTime != startDateTime.getTime() ||
             currentEndTime === null || currentEndTime != endDateTime.getTime()) {
            currentStartTime = startDateTime.getTime();
            currentEndTime = endDateTime.getTime();
            startGpsIndex = gpsPoints.indexOfLambda(function(x) { return(currentStartTime <= parseDate(x[1]).getTime()); });
            endGpsIndex = gpsPoints.indexOfLambda(function(x) { return(currentEndTime < parseDate(x[1]).getTime()); });
            currentPath = [];
            currentBounds = new google.maps.LatLngBounds();
            if(process) {
              clearTimeout(process);
              process = null;
            }
          }
          //console.debug(process + " " + keepGoing + " " + startGpsIndex);
          var currentTime = (new Date()).getTime();
          while(startGpsIndex < endGpsIndex && ((new Date()).getTime()-currentTime < 50)) {
            if(gpsPoints[startGpsIndex].length == 4) {
              gpsPoints[startGpsIndex].push(new google.maps.LatLng(gpsPoints[startGpsIndex][2],gpsPoints[startGpsIndex][3]));
            }
            currentPath.push(gpsPoints[startGpsIndex][4]);
            currentBounds.extend(gpsPoints[startGpsIndex][4]);
            startGpsIndex++;
          }
          if(startGpsIndex >= endGpsIndex) {
            if(autoZoom) {
              map.fitBounds(currentBounds);
            }
            if(force) {
              routePolyline.setPath(currentPath);
            }
            process = null;
          } else {
            process = setTimeout(function () { updatePath(force, autoZoom, true); }, 0);
          }
        }
      }();
      
      function playRoute() {
        if($("#date_slider").slider("values", 1) < $("#date_slider").slider("option", "max")) {
          var step = (maxDateTime - minDateTime) / 1000;
          $("#date_slider").slider("values", 0, $("#date_slider").slider("values", 0)+step);
          $("#date_slider").slider("values", 1, $("#date_slider").slider("values", 1)+step);
          $("#date_slider").trigger("slide", $("#date_slider").slider("option"));
          setTimeout(playRoute, 100);
        }
      }
      
      function getURLParameter(url, name) {
        var search = /[^?](.*)/.exec(url)[1] || "";
        return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search)||[,""])[1].replace(/\+/g, '%20'))||null;
      }

      function urlToMapOptions(location) {
        var mapOptions = {};
        
        var t = getURLParameter(location, "t") || "m";
        if(t == "h") { mapOptions.mapTypeId = google.maps.MapTypeId.HYBRID; }
        if(t == "m") { mapOptions.mapTypeId = google.maps.MapTypeId.ROADMAP; }
        if(t == "k") { mapOptions.mapTypeId = google.maps.MapTypeId.SATELLITE; }
        if(t == "p") { mapOptions.mapTypeId = google.maps.MapTypeId.TERRAIN; }
        
        var z = getURLParameter(location, "z") || "0";
        mapOptions.zoom = +z;
        
        var ll = getURLParameter(location, "ll") || "0,0";
        ll = /(.*),(.*)/.exec(ll);
        if(ll && !isNaN(ll[1]) && !isNaN(ll[2])) {
          mapOptions.center = new google.maps.LatLng(+ll[1],+ll[2]);
        } else {
          mapOptions.center = new google.maps.LatLng(0,0);
        }
        return(mapOptions);
      }
      
      function mapOptionsToUrl() {
        var url = /^[^?]*/.exec(location.href)[0];
        var t = map.getMapTypeId() || google.maps.MapTypeId.ROADMAP;
        if(t == google.maps.MapTypeId.HYBRID) { t = "h"; }
        if(t == google.maps.MapTypeId.ROADMAP) { t = "m"; }
        if(t == google.maps.MapTypeId.SATELLITE) { t = "k"; }
        if(t == google.maps.MapTypeId.TERRAIN) { t = "p"; }
        var querystring = "?t=" + t;
        querystring += "&z=" + map.getZoom();
        querystring += "&ll=" + map.getCenter().lat() + "," + map.getCenter().lng();
        querystring += "&startdatetime=" + startDateTime.getTime();
        querystring += "&enddatetime=" + endDateTime.getTime();
        $("#perm_link").attr("href", url + querystring);
        history.replaceState(null, null, url + querystring);
      }

      function initialize() {
        map = new google.maps.Map(document.getElementById('map_canvas'), urlToMapOptions(location.href));

        var gpsPath;
        var polyOptions = {
          path: new google.maps.MVCArray(),
          strokeColor: $(".ui-slider-range").css("background-color"),
          strokeOpacity: 0.5,
          strokeWeight: 5,
          geodesic: true,
          clickable: false
        }
        routePolyline = new google.maps.Polyline(polyOptions);
        google.maps.event.addListener(routePolyline, 'mouseover', function (e) {
          routePolyline.setOptions({strokeOpacity: 1.0});
        });
        google.maps.event.addListener(routePolyline, 'mouseout', function (e) {
          routePolyline.setOptions({strokeOpacity: 0.5});
        });
        routePolyline.setMap(map);
        photoInfoWindow = new google.maps.InfoWindow({
          content: ''
        });
        $.getJSON("tile_info.json", function(data) {
          $.each(data, function(key, val) {
            if(key == "tiles") {
              $.each(val, function (zoom_level, val) {
                tileFiles[zoom_level] = {};
                $.each(val, function (logTileSize, val) {
                  tileFiles[zoom_level][logTileSize] = {};
                  $.each(val, function (index, tileFilename) {
                    tileFiles[zoom_level][logTileSize][tileFilename] = 0;
                  });
                });
              });
            } else if(key == "start_datetime") {
              minDateTime = parseDate(val);
            } else if(key == "end_datetime") {
              maxDateTime = parseDate(val);
            } else if(key == "generation_datetime") {
              tileGenerationDateTime = val;
            }
          });
          $.ui.slider.prototype._slide = function ( event, index, newVal ) {
            var otherVal,
              newValues,
              allowed;

            if ( this.options.values && this.options.values.length ) {
              otherVal = this.values( index ? 0 : 1 );
              if ( newVal !== this.values( index ) ) {
                newValues = this.values();
                newValues[ index ] = newVal;
                // A slide can be canceled by returning false from the slide callback
                allowed = this._trigger( "slide", event, {
                  handle: this.handles[ index ],
                  value: newVal,
                  values: newValues
                } );
                otherVal = this.values( index ? 0 : 1 );
                if ( allowed !== false ) {
                  this.values( index, newVal, true );
                }
              }
            } else {
              if ( newVal !== this.value() ) {
                // A slide can be canceled by returning false from the slide callback
                allowed = this._trigger( "slide", event, {
                  handle: this.handles[ index ],
                  value: newVal
                } );
                if ( allowed !== false ) {
                  this.value( newVal );
                }
              }
            }
          }
          Date.prototype.clone = function() { return parseDate(this.getTime()); };
          Date.prototype.addDays = function(days) { this.setDate(this.getDate() + days); return this; }
          Date.prototype.addMilliseconds = function(ms) { this.setMilliseconds(this.getMilliseconds() + ms); return this; }
          startDateTime = parseDate(getURLParameter(location.href, "startdatetime"));
          if(!startDateTime || startDateTime < minDateTime || startDateTime > maxDateTime) {
            startDateTime = minDateTime.clone();
          }
          endDateTime = parseDate(getURLParameter(location.href, "enddatetime"));
          if(!endDateTime || endDateTime < minDateTime || endDateTime > maxDateTime) {
            endDateTime = maxDateTime.clone();
          }
          var totalMilliseconds = maxDateTime - minDateTime;
          var div = document.createElement("div");
          div.innerHTML = '<div id="date_slider"></div>' +
                          '<input type="text" id="start_datepicker" />' +
                          '<input type="text" id="end_datepicker" />' +
                          '<label for="auto_zoom">Auto-Zoom</label><input type="checkbox" id="auto_zoom" checked />' + 
                          '<input type="button" value="Zoom" onClick="load_polyline(false,true);" />' + 
                          '<img src="images/play.png" onclick="playRoute();">' + 
                          '<label for="#password_text">Passwords:</label><input type="text" id="password_text" />' +
                          '<a href="" id="perm_link"><img src="images/chain.png"></a>';
          div.id = "date_range";
          map.controls[google.maps.ControlPosition.BOTTOM_LEFT].push(div);
          $(div.getElementById("date_slider")).slider({animate: true, "step": 1, min: minDateTime.getTime(), max: maxDateTime.getTime(), values: [startDateTime.getTime(), endDateTime.getTime()], range: true});
          $(div.getElementById("date_slider")).on("slide", function(event, ui) {
            if(ui.values[0] >= ui.values[1]) {
              if(ui.handle == $("#date_slider a")[0]) {
                if(ui.value == maxDateTime.getTime()) { return(false); }
                $("#date_slider").slider("values", 1, ui.value+1);
                ui.values[0] = ui.value;
                ui.values[1] = ui.value+1;
              } else {
                if(ui.value == minDateTime.getTime()) { return(false); }
                $("#date_slider").slider("values", 0, ui.value-1);
                ui.values[0] = ui.value-1;
                ui.values[1] = ui.value;
              }
            }
            if($("#start_datepicker").datepicker("getDate").getTime() != ui.values[0]) {
              startDateTime = parseDate(ui.values[0]);
              mapOptionsToUrl();
              $("#start_datepicker").datepicker("setDate", startDateTime);
            }
            if($("#end_datepicker").datepicker("getDate").getTime() != ui.values[1]) {
              endDateTime = parseDate(ui.values[1]-1);
              mapOptionsToUrl();
              $("#end_datepicker").datepicker("setDate", endDateTime);
            }
            var sliderTimeout = null;
            if(!sliderTimeout) {
              sliderTimeout = setTimeout(function () {
                clearTimeout(sliderTimeout);
                load_polyline(true, $(div.getElementById("auto_zoom"))[0].checked);
                load_photos();
                sliderTimeout = null;
              }, 500);
            }
          });
          $(div.getElementById("start_datepicker")).datepicker({appendText: "(yyyy-mm-dd)", dateFormat: "yy-mm-dd", minDate: minDateTime, maxDate: maxDateTime, defaultDate: startDateTime,
                                             showOn: "button", buttonImageOnly: true, buttonImage: "images/calendar.gif",
                                             onSelect: function(dateText, inst) {
                                               var newStartDateTime = $("#start_datepicker").datepicker("getDate");
                                               if(newStartDateTime.getTime() != $("#date_slider").slider('values', 0)) {
                                                 startDateTime = newStartDateTime;
                                                 mapOptionsToUrl();
                                                 $("#date_slider").slider("values", 0, newStartDateTime);
                                               }
                                               load_polyline(true, $("#auto_zoom")[0].checked);
                                               load_photos();
                                             }
                                            });
          $(div.getElementById("start_datepicker")).datepicker("setDate", startDateTime);
          $(div.getElementById("end_datepicker")).datepicker({appendText: "(yyyy-mm-dd)", dateFormat: "yy-mm-dd", minDate: minDateTime, maxDate: maxDateTime, defaultDate: endDateTime,
                                           showOn: "button", buttonImageOnly: true, buttonImage: "images/calendar.gif",
                                           onSelect: function(dateText, inst) {
                                             var newEndDateTime = $("#end_datepicker").datepicker("getDate").clone().addDays(1).addMilliseconds(-1);
                                             if(newEndDateTime > maxDateTime) {
                                               newEndDateTime = maxDateTime;
                                             }
                                             if(newEndDateTime.getTime() != $("#date_slider").slider('values', 1)) {
                                               endDateTime = newEndDateTime;
                                               mapOptionsToUrl();
                                               $("#date_slider").slider("values", 1, newEndDateTime);
                                             }
                                             load_polyline(true, $("#auto_zoom")[0].checked);
                                             load_photos();
                                           }
                                          });
          $(div.getElementById("end_datepicker")).datepicker("setDate", endDateTime.clone().addMilliseconds(-1));
          var passwordTimeout;
          $(div.getElementById("password_text")).on('input', function(e) {
            if(passwordTimeout) {
              clearTimeout(passwordTimeout);
              passwordTimeout = null;
            }
            passwordTimeout = setTimeout(checkPasswords, 1000);
          });
          function checkPasswords() {
            var passwordsToTest = $.map($(div.getElementById("password_text")).val().split(','), function(testPassword) { return($.trim(testPassword)); });
            passwords = $.grep(passwords, function(plainAndDerived) { //remove passwords that are no longer in the textbox
              return(passwordsToTest.indexOf(plainAndDerived[0]) != -1);
            });
            passwordsToTest = $.grep(passwordsToTest, function(testPassword) {
              if(testPassword === "") {
                return(0);
              }
              for(var i=0; i < passwords.length; i++) {
                if(testPassword == passwords[i][0]) {
                  return(0);  //don't test, we have it already
                }
              }
              return(1);
            });
            var newPasswords = false;
            $.each(passwordsToTest, function(index, testPassword) {
              $.each(passwordInfo, function(index, passPair) {
                var salt = CryptoJS.enc.Hex.parse(passPair[0]);
                var keyHex = CryptoJS.PBKDF2(testPassword, salt, { keySize: 128/32, iterations: 1000 }).toString();
                //check if key decrypts the pair
                var key0 = decryptHex(keyHex, passPair[1]);
                var key1 = decryptHex(keyHex, passPair[2]);
                if((parseInt(key0.substr(0,2),16)+1)%255 == parseInt(key1.substr(0,2),16) &&
                   key0.substr(2) == key1.substr(2)) {  //found!
                  passwords.push([testPassword, keyHex]);
                  newPasswords = true;
                  //return(false);  //comment in case the password is used twice with different salts.  Weird.
                }
              });
            });
            if(newPasswords) {
              load_photos();
            }
          }
          google.maps.event.addListener(map, 'idle', load_polyline);
          google.maps.event.addListener(map, 'idle', load_photos);
          google.maps.event.addListener(map, 'idle', function() {
            mapOptionsToUrl();
          });
          google.maps.event.addListener(map, 'maptypeid_changed', function() {
            mapOptionsToUrl();
          });
        });
        $.getJSON("photo_info.json", function(data) {
          $.each(data, function(key, val) {
            if(key == 'photos') {
              photoInfo = val;
            } else if(key == 'passwords') {
              passwordInfo = val;
            }
          });
        });
      }
      $(document).ready(initialize);
    </script>
  </head>
  <body>
    <div id="map_canvas"></div>
  </body>
</html>
